import { EditorState, Plugin, PluginKey } from 'prosemirror-state'
import { Decoration, DecorationSet, EditorView } from 'prosemirror-view'
import { BlockNoteEditor } from '../../../BlockNoteEditor'
import { BlockSchema } from '../../../extensions/Blocks/api/blockTypes'
import findBlock from '../../../extensions/Blocks/helpers/findBlock'
import { BaseUiElementState } from '../../BaseUiElementTypes'
import { SuggestionItem } from './SuggestionItem'

export type SuggestionsMenuState<T extends SuggestionItem> = BaseUiElementState & {
  // The suggested items to display.
  filteredItems: T[]
  // The index of the suggested item that's currently hovered by the keyboard.
  keyboardHoveredItemIndex: number
}

function getDefaultPluginState<T extends SuggestionItem>(): SuggestionPluginState<T> {
  return {
    active: false,
    triggerCharacter: undefined,
    queryStartPos: undefined,
    items: [] as T[],
    keyboardHoveredItemIndex: undefined,
    notFoundCount: 0,
    decorationId: undefined,
  }
}

class SuggestionsMenuView<T extends SuggestionItem, BSchema extends BlockSchema> {
  private suggestionsMenuState?: SuggestionsMenuState<T>

  public updateSuggestionsMenu: () => void

  pluginState: SuggestionPluginState<T>

  constructor(
    private readonly editor: BlockNoteEditor<BSchema>,
    private readonly pluginKey: PluginKey,
    updateSuggestionsMenu: (suggestionsMenuState: SuggestionsMenuState<T>) => void = () => {
      // noop
    },
  ) {
    this.pluginState = getDefaultPluginState<T>()

    this.updateSuggestionsMenu = () => {
      if (!this.suggestionsMenuState) {
        throw new Error('Attempting to update uninitialized suggestions menu')
      }

      updateSuggestionsMenu(this.suggestionsMenuState)
    }

    document.addEventListener('scroll', this.handleScroll)
  }

  handleScroll = () => {
    if (this.suggestionsMenuState?.show) {
      const decorationNode = document.querySelector(`[data-decoration-id="${this.pluginState.decorationId}"]`)
      this.suggestionsMenuState.referencePos = decorationNode!.getBoundingClientRect()
      this.updateSuggestionsMenu()
    }
  }

  update(view: EditorView, prevState: EditorState) {
    const prev = this.pluginKey.getState(prevState)
    const next = this.pluginKey.getState(view.state)

    // See how the state changed
    const started = !prev.active && next.active
    const stopped = prev.active && !next.active
    // TODO: Currently also true for cases in which an update isn't needed so selected list item index updates still
    //  cause the view to update. May need to be more strict.
    const changed = prev.active && next.active

    // Cancel when suggestion isn't active
    if (!started && !changed && !stopped) {
      return
    }

    this.pluginState = stopped ? prev : next

    if (stopped || !this.editor.isEditable) {
      this.suggestionsMenuState!.show = false
      this.updateSuggestionsMenu()

      return
    }

    const decorationNode = document.querySelector(`[data-decoration-id="${this.pluginState.decorationId}"]`)

    if (this.editor.isEditable) {
      this.suggestionsMenuState = {
        show: true,
        referencePos: decorationNode!.getBoundingClientRect(),
        filteredItems: this.pluginState.items,
        keyboardHoveredItemIndex: this.pluginState.keyboardHoveredItemIndex!,
      }

      this.updateSuggestionsMenu()
    }
  }

  destroy() {
    document.removeEventListener('scroll', this.handleScroll)
  }
}

type SuggestionPluginState<T extends SuggestionItem> = {
  // True when the menu is shown, false when hidden.
  active: boolean
  // The character that triggered the menu being shown. Allowing the trigger to be different to the default
  // trigger allows other extensions to open it programmatically.
  triggerCharacter: string | undefined
  // The editor position just after the trigger character, i.e. where the user query begins. Used to figure out
  // which menu items to show and can also be used to delete the trigger character.
  queryStartPos: number | undefined
  // The items that should be shown in the menu.
  items: T[]
  // The index of the item in the menu that's currently hovered using the keyboard.
  keyboardHoveredItemIndex: number | undefined
  // The number of characters typed after the last query that matched with at least 1 item. Used to close the
  // menu if the user keeps entering queries that don't return any results.
  notFoundCount: number | undefined
  decorationId: string | undefined
}

/**
 * A ProseMirror plugin for suggestions, designed to make '/'-commands possible as well as mentions.
 *
 * This is basically a simplified version of TipTap's [Suggestions](https://github.com/ueberdosis/tiptap/tree/db92a9b313c5993b723c85cd30256f1d4a0b65e1/packages/suggestion) plugin.
 *
 * This version is adapted from the aforementioned version in the following ways:
 * - This version supports generic items instead of only strings (to allow for more advanced filtering for example)
 * - This version hides some unnecessary complexity from the user of the plugin.
 * - This version handles key events differently
 */
export const setupSuggestionsMenu = <T extends SuggestionItem, BSchema extends BlockSchema>(
  editor: BlockNoteEditor<BSchema>,
  updateSuggestionsMenu: (suggestionsMenuState: SuggestionsMenuState<T>) => void,
  pluginKey: PluginKey,
  defaultTriggerCharacter: string,
  items: (query: string) => T[] = () => [],
  onSelectItem: (props: { item: T; editor: BlockNoteEditor<BSchema> }) => void = () => {
    // noop
  },
) => {
  // Assertions
  if (defaultTriggerCharacter.length !== 1) {
    throw new Error("'char' should be a single character")
  }

  let suggestionsPluginView: SuggestionsMenuView<T, BSchema>

  const deactivate = (view: EditorView) => {
    view.dispatch(view.state.tr.setMeta(pluginKey, { deactivate: true }))
  }

  return {
    plugin: new Plugin({
      key: pluginKey,

      view: () => {
        suggestionsPluginView = new SuggestionsMenuView<T, BSchema>(
          editor,
          pluginKey,

          updateSuggestionsMenu,
        )
        return suggestionsPluginView
      },

      state: {
        // Initialize the plugin's internal state.
        init(): SuggestionPluginState<T> {
          return getDefaultPluginState<T>()
        },

        // Apply changes to the plugin state from an editor transaction.
        apply(transaction, prev, oldState, newState): SuggestionPluginState<T> {
          // TODO: More clearly define which transactions should be ignored.
          if (transaction.getMeta('orderedListIndexing') !== undefined) {
            return prev
          }

          // Checks if the menu should be shown.
          if (transaction.getMeta(pluginKey)?.activate) {
            return {
              active: true,
              triggerCharacter: transaction.getMeta(pluginKey)?.triggerCharacter || '',
              queryStartPos: newState.selection.from,
              items: items(''),
              keyboardHoveredItemIndex: 0,
              // TODO: Maybe should be 1 if the menu has no possible items? Probably redundant since a menu with no items
              //  is useless in practice.
              notFoundCount: 0,
              decorationId: `id_${Math.floor(Math.random() * 0xffffffff)}`,
            }
          }

          // Checks if the menu is hidden, in which case it doesn't need to be hidden or updated.
          if (!prev.active) {
            return prev
          }

          const next = { ...prev }

          // Updates which menu items to show by checking which items the current query (the text between the trigger
          // character and caret) matches with.
          next.items = items(newState.doc.textBetween(prev.queryStartPos!, newState.selection.from))

          // Updates notFoundCount if the query doesn't match any items.
          next.notFoundCount = 0
          if (next.items.length === 0) {
            // Checks how many characters were typed or deleted since the last transaction, and updates the notFoundCount
            // accordingly. Also ensures the notFoundCount does not become negative.
            next.notFoundCount = Math.max(0, prev.notFoundCount! + (newState.selection.from - oldState.selection.from))
          }

          // Hides the menu. This is done after items and notFoundCount are already updated as notFoundCount is needed to
          // check if the menu should be hidden.
          if (
            // Highlighting text should hide the menu.
            newState.selection.from !== newState.selection.to ||
            // Transactions with plugin metadata {deactivate: true} should hide the menu.
            transaction.getMeta(pluginKey)?.deactivate ||
            // Certain mouse events should hide the menu.
            // TODO: Change to global mousedown listener.
            transaction.getMeta('focus') ||
            transaction.getMeta('blur') ||
            transaction.getMeta('pointer') ||
            // Moving the caret before the character which triggered the menu should hide it.
            (prev.active && newState.selection.from < prev.queryStartPos!) ||
            // Entering more than 3 characters, after the last query that matched with at least 1 menu item, should hide
            // the menu.
            next.notFoundCount > 3
          ) {
            return getDefaultPluginState<T>()
          }

          // Updates keyboardHoveredItemIndex if the up or down arrow key was
          // pressed, or resets it if the keyboard cursor moved.
          if (transaction.getMeta(pluginKey)?.selectedItemIndexChanged !== undefined) {
            let newIndex = transaction.getMeta(pluginKey).selectedItemIndexChanged

            // Allows selection to jump between first and last items.
            if (newIndex < 0) {
              newIndex = prev.items.length - 1
            } else if (newIndex >= prev.items.length) {
              newIndex = 0
            }

            next.keyboardHoveredItemIndex = newIndex
          } else if (oldState.selection.from !== newState.selection.from) {
            next.keyboardHoveredItemIndex = 0
          }

          return next
        },
      },

      props: {
        handleKeyDown(view, event) {
          const menuIsActive = (this as Plugin).getState(view.state).active

          // Shows the menu if the default trigger character was pressed and the menu isn't active.
          if (event.key === defaultTriggerCharacter && !menuIsActive) {
            view.dispatch(
              view.state.tr.insertText(defaultTriggerCharacter).scrollIntoView().setMeta(pluginKey, {
                activate: true,
                triggerCharacter: defaultTriggerCharacter,
              }),
            )

            return true
          }

          // Doesn't handle other keystrokes if the menu isn't active.
          if (!menuIsActive) {
            return false
          }

          // Handles keystrokes for navigating the menu.
          // eslint-disable-next-line @typescript-eslint/no-shadow
          const { triggerCharacter, queryStartPos, items, keyboardHoveredItemIndex } = pluginKey.getState(view.state)

          // Moves the keyboard selection to the previous item.
          if (event.key === 'ArrowUp') {
            view.dispatch(
              view.state.tr.setMeta(pluginKey, {
                selectedItemIndexChanged: keyboardHoveredItemIndex - 1,
              }),
            )
            return true
          }

          // Moves the keyboard selection to the next item.
          if (event.key === 'ArrowDown') {
            view.dispatch(
              view.state.tr.setMeta(pluginKey, {
                selectedItemIndexChanged: keyboardHoveredItemIndex + 1,
              }),
            )
            return true
          }

          // Selects an item and closes the menu.
          if (event.key === 'Enter') {
            deactivate(view)
            editor._tiptapEditor
              .chain()
              .focus()
              .deleteRange({
                from: queryStartPos! - triggerCharacter!.length,
                to: editor._tiptapEditor.state.selection.from,
              })
              .run()

            onSelectItem({
              item: items[keyboardHoveredItemIndex],
              editor: editor,
            })

            return true
          }

          // Closes the menu.
          if (event.key === 'Escape') {
            deactivate(view)
            return true
          }

          return false
        },

        // Setup decorator on the currently active suggestion.
        decorations(state) {
          const { active, decorationId, queryStartPos, triggerCharacter } = (this as Plugin).getState(state)

          if (!active) {
            return null
          }

          // If the menu was opened programmatically by another extension, it may not use a trigger character. In this
          // case, the decoration is set on the whole block instead, as the decoration range would otherwise be empty.
          if (triggerCharacter === '') {
            const blockNode = findBlock(state.selection)
            if (blockNode) {
              return DecorationSet.create(state.doc, [
                Decoration.node(blockNode.pos, blockNode.pos + blockNode.node.nodeSize, {
                  nodeName: 'span',
                  class: 'suggestion-decorator',
                  'data-decoration-id': decorationId,
                }),
              ])
            }
          }
          // Creates an inline decoration around the trigger character.
          return DecorationSet.create(state.doc, [
            Decoration.inline(queryStartPos - triggerCharacter.length, queryStartPos, {
              nodeName: 'span',
              class: 'suggestion-decorator',
              'data-decoration-id': decorationId,
            }),
          ])
        },
      },
    }),
    itemCallback: (item: T) => {
      deactivate(editor._tiptapEditor.view)
      editor._tiptapEditor
        .chain()
        .focus()
        .deleteRange({
          from:
            suggestionsPluginView.pluginState.queryStartPos! -
            suggestionsPluginView.pluginState.triggerCharacter!.length,
          to: editor._tiptapEditor.state.selection.from,
        })
        .run()

      onSelectItem({
        item: item,
        editor: editor,
      })
    },
  }
}
